#!python3

# License: Apache 2.0. See LICENSE file in root directory.
# Copyright(c) 2020 Intel Corporation. All Rights Reserved.

#
# Syntax:
#     unit-test-config.py <dir> <build-dir>
#
# Looks for possible single-file unit-testing targets (test-*) in $dir, and builds
# a CMakeLists.txt in $builddir to compile them.
#
# Each target is compiled in its own project, so that each file ends up in a different
# process and so individual tests cannot affect others except through hardware.
#

import sys, os, subprocess, locale, re
from glob import glob

if len(sys.argv) != 3:
    ourname = os.path.basename(sys.argv[0])
    print( 'Syntax: ' + ourname + ' <dir> <build-dir>' )
    print( '        build unit-testing framework for the tree in $dir' )
    exit(1)
dir=sys.argv[1]
builddir=sys.argv[2]
if not os.path.isdir( dir ):
    print( 'FATAL  Directory not found:', dir )
    exit(1)
if not os.path.isdir( builddir ):
    print( 'FATAL  Directory not found:', builddir )
    exit(1)

have_errors = False

def debug(*args):
    #print( '-D-', *args )
    pass
def error(*args):
    print( '-E-', *args )
    global have_errors
    have_errors = True
def filesin( root ):
    # Yield all files found in root, using relative names ('root/a' would be yielded as 'a')
    for (path,subdirs,leafs) in os.walk( root ):
        for leaf in leafs:
            # We have to stick to Unix conventions because CMake on Windows is fubar...
            yield os.path.relpath( path + '/' + leaf, root ).replace( '\\', '/' )
def find( dir, mask ):
    pattern = re.compile( mask )
    for leaf in filesin( dir ):
        if pattern.search( leaf ):
            debug(leaf)
            yield leaf


def remove_newlines (lines):
    for line in lines:
        if line[-1] == '\n':
            line = line[:-1]    # excluding the endline
        yield line

def grep_( pattern, lines, context ):
    index = 0
    matches = 0
    for line in lines:
        index = index + 1
        match = pattern.search( line )
        if match:
            context['index'] = index
            context['line']  = line
            context['match'] = match
            yield context
            matches = matches + 1
    if matches:
        del context['index']
        del context['line']
        del context['match']
    # UnicodeDecodeError can be thrown in binary files

def grep( expr, *args ):
    #debug( f"grep {expr} {args}" )
    pattern = re.compile( expr )
    context = dict()
    for filename in args:
        context['filename'] = filename
        with open( filename, errors = 'ignore' ) as file:
            for line in grep_( pattern, remove_newlines( file ), context ):
                yield context

def generate_cmake( builddir, testdir, testname, filelist ):
    makefile = builddir + '/' + testdir + '/CMakeLists.txt'
    debug( '   creating:', makefile )
    handle = open( makefile, 'w' );
    filelist = '\n    '.join( filelist )
    handle.write( '''
# This file is automatically generated!!
# Do not modify or your changes will be lost!

cmake_minimum_required( VERSION 3.1.0 )
project( ''' + testname + ''' )

set( SRC_FILES ''' + filelist + '''
)
add_executable( ''' + testname + ''' ${SRC_FILES} )
source_group( "Common Files" FILES ${ELPP_FILES} ${CATCH_FILES} )
set_property(TARGET ''' + testname + ''' PROPERTY CXX_STANDARD 11)
target_link_libraries( ''' + testname + ''' ${DEPENDENCIES})

set_target_properties( ''' + testname + ''' PROPERTIES FOLDER "Unit-Tests/''' + os.path.dirname( testdir ) + '''" )

''' )
    handle.close()

# Recursively searches a .cpp file for #include directives and returns
# a set of all of them.
#
# Only directives that are relative to the current path (#include "<path>")
# are looked for!
#
def find_includes( filepath ):
    filelist = set()
    filedir = os.path.dirname(filepath)
    for context in grep( '^\s*#\s*include\s+"(.*)"\s*$', filepath ):
        m = context['match']
        index = context['index']
        include = m.group(1)
        if not os.path.isabs( include ):
            include = os.path.normpath( filedir + '/' + include )
        include = include.replace( '\\', '/' )
        if os.path.exists( include ):
            filelist.add( include )
            filelist |= find_includes( include )
    return filelist

def process_cpp( dir, builddir ):
    found = []
    shareds = []
    statics = []
    for f in find( dir, '(^|/)test-.*\.cpp$' ):
        testdir = os.path.splitext( f )[0]                          # "log/internal/test-all"  <-  "log/internal/test-all.cpp"
        testparent = os.path.dirname(testdir)                       # "log/internal"
        # Each CMakeLists.txt sits in its own directory
        os.makedirs( builddir + '/' + testdir, exist_ok = True );   # "build/log/internal/test-all"
        # We need the project name unique: keep the path but make it nicer:
        testname = 'test-' + testparent.replace( '/', '-' ) + '-' + os.path.basename(testdir)[5:]   # "test-log-internal-all"
        # Build the list of files we want in the project:
        # At a minimum, we have the original file, plus any common files
        filelist = [ dir + '/' + f, '${ELPP_FILES}', '${CATCH_FILES}' ]
        # Add any "" includes specified in the .cpp that we can find
        includes = find_includes( dir + '/' + f )
        # Add any files explicitly listed in the .cpp itself, like this:
        #         //#cmake:add-file <filename>
        # Any files listed are relative to $dir
        shared = False
        static = False
        for context in grep( '^//#cmake:\s*', dir + '/' + f ):
            m = context['match']
            index = context['index']
            cmd, *rest = context['line'][m.end():].split()
            if cmd == 'add-file':
                for additional_file in rest:
                    files = additional_file
                    if not os.path.isabs( additional_file ):
                        files = dir + '/' + testparent + '/' + additional_file
                    files = glob( files )
                    if not files:
                        error( f + '+' + str(index) + ': no files match "' + additional_file + '"' )
                    for abs_file in files:
                        abs_file = os.path.normpath( abs_file )
                        abs_file = abs_file.replace( '\\', '/' )
                        if not os.path.exists( abs_file ):
                            error( f + '+' + str(index) + ': file not found "' + additional_file + '"' )
                        debug( '   add file:', abs_file )
                        filelist.append( abs_file )
                        if( os.path.splitext( abs_file )[0] == 'cpp' ):
                            # Add any "" includes specified in the .cpp that we can find
                            includes |= find_includes( abs_file )
            elif cmd == 'static!':
                if len(rest):
                    error( f + '+' + str(index) + ': unexpected arguments past \'' + cmd + '\'' )
                elif shared:
                    error( f + '+' + str(index) + ': \'' + cmd + '\' mutually exclusive with \'shared!\'' )
                else:
                    static = True
            elif cmd == 'shared!':
                if len(rest):
                    error( f + '+' + str(index) + ': unexpected arguments past \'' + cmd + '\'' )
                elif static:
                    error( f + '+' + str(index) + ': \'' + cmd + '\' mutually exclusive with \'static!\'' )
                else:
                    shared = True
            else:
                error( f + '+' + str(index) + ': unknown cmd \'' + cmd + '\' (should be \'add-file\', \'static!\', or \'shared!\')' )
        for include in includes:
            filelist.append( include )
        generate_cmake( builddir, testdir, testname, filelist )
        if static:
            statics.append( testdir )
        elif shared:
            shareds.append( testdir )
        else:
            found.append( testdir )
    return found, shareds, statics
def process_py( dir, builddir ):
    # TODO
    return [],[],[]

normal_tests = []
shared_tests = []
static_tests = []
n,sh,st = process_cpp( dir, builddir )
normal_tests.extend( n )
shared_tests.extend( sh )
static_tests.extend( st )
n,sh,st = process_py( dir, builddir )
normal_tests.extend( n )
shared_tests.extend( sh )
static_tests.extend( st )

cmakefile = builddir + '/CMakeLists.txt'
name = os.path.basename( os.path.realpath( dir ))
debug( 'Creating "' + name + '" project in', cmakefile )

handle = open( cmakefile, 'w' );
handle.write( '''

# We make use of ELPP (EasyLogging++):
include_directories( ''' + dir +  '''/../third-party/easyloggingpp/src )
set( ELPP_FILES
    ''' + dir + '''/../third-party/easyloggingpp/src/easylogging++.cc
    ''' + dir + '''/../third-party/easyloggingpp/src/easylogging++.h
)
set( CATCH_FILES
    ''' + dir + '''/catch/catch.hpp
)

''' )

n_tests = 0
for sdir in normal_tests:
    handle.write( 'add_subdirectory( ' + sdir + ' )\n' )
    debug( '... including:', sdir )
    n_tests += 1
if len(shared_tests):
    handle.write( 'if(NOT ${BUILD_SHARED_LIBS})\n' )
    handle.write( '    message( INFO "' + str(len(shared_tests)) + ' shared lib unit-tests will be skipped. Check BUILD_SHARED_LIBS to run them..." )\n' )
    handle.write( 'else()\n' )
    for test in shared_tests:
        handle.write( '    add_subdirectory( ' + test + ' )\n' )
        debug( '... including:', sdir )
        n_tests += 1
    handle.write( 'endif()\n' )
if len(static_tests):
    handle.write( 'if(${BUILD_SHARED_LIBS})\n' )
    handle.write( '    message( INFO "' + str(len(static_tests)) + ' static lib unit-tests will be skipped. Uncheck BUILD_SHARED_LIBS to run them..." )\n' )
    handle.write( 'else()\n' )
    for test in static_tests:
        handle.write( '    add_subdirectory( ' + test + ' )\n' )
        debug( '... including:', sdir )
        n_tests += 1
    handle.write( 'endif()\n' )
handle.close()

print( 'Generated ' + str(n_tests) + ' unit-tests' )
if have_errors:
    exit(1)
exit(0)

